00
⚠ The problem
Activity does everything. You can't test any of it. Rotate the phone → crash. Add a feature → break three others.✦ What "worked"
Easy to start. No boilerplate. For a 1-screen demo it's fine. In production it becomes a 1000-line monster.// ❌ NO ARCHITECTURE — Everything in one Activity // UI + Network + Business Logic + State = one God class class UserProfileActivity : AppCompatActivity() { // UI references grabbed manually — tightly coupled to layout private lateinit var tvName: TextView private lateinit var tvEmail: TextView private lateinit var ivAvatar: ImageView private lateinit var progressBar: ProgressBar private lateinit var tvError: TextView // Raw OkHttp — no abstraction, no reuse private val client = OkHttpClient() @Override override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_user_profile) tvName = findViewById(R.id.tvName) tvEmail = findViewById(R.id.tvEmail) ivAvatar = findViewById(R.id.ivAvatar) progressBar = findViewById(R.id.progressBar) tvError = findViewById(R.id.tvError) val userId = intent.getStringExtra("USER_ID") ?: return // ❌ Network call directly on main thread? Crash! // ❌ Must use Thread — no coroutines, no lifecycle awareness fetchUser(userId) } private fun fetchUser(userId: String) { // ❌ Show loading state — scattered UI logic progressBar.visibility = View.VISIBLE tvError.visibility = View.GONE // ❌ Raw thread — rotate phone here and the Activity is GONE // ❌ but this Thread is still running → NullPointerException Thread { try { val request = Request.Builder() .url("https://api.example.com/users/$userId") .build() val response = client.newCall(request).execute() val body = response.body?.string() ?: throw Exception("Empty body") // ❌ Manual JSON parsing — no Gson/Moshi, no data class val json = JSONObject(body) val name = json.getString("name") val email = json.getString("email") val avatar = json.getString("avatarUrl") // ❌ runOnUiThread needed — easy to forget, easy to crash runOnUiThread { progressBar.visibility = View.GONE tvName.text = name tvEmail.text = email // ❌ Glide called inside Thread callback — messy Glide.with(this).load(avatar).into(ivAvatar) } } catch (e: Exception) { runOnUiThread { progressBar.visibility = View.GONE tvError.visibility = View.VISIBLE tvError.text = "Error: ${e.message}" } } }.start() } // ❌ Zero unit tests possible — everything needs a real Android device // ❌ Rotate screen → thread crashes into dead Activity // ❌ Need the same logic elsewhere? Copy-paste only option }
Key problems visible in this code: Thread running after Activity is destroyed causes NPE on rotation. JSON parsing, network, and UI rendering are all in the same function. No way to unit test any of this. Zero reusability.
01
⚠ Inherited problem
Activity is still the Controller AND the View. Can't test Controller in isolation — it has Activity/Context references baked in.✦ What improved
Model (User data class + UserService) is now separately testable. Business logic lives outside the Activity. First real separation.// ✅ MODEL — Pure data class, zero Android dependencies // This can be unit-tested anywhere on any JVM data class User( val id: String, val name: String, val email: String, val avatarUrl: String )
// ✅ MODEL — UserService handles data fetching logic // Separate from Activity — reusable, partially testable interface UserCallback { fun onSuccess(user: User) fun onError(message: String) } class UserService { private val retrofit = Retrofit.Builder() .baseUrl("https://api.example.com/") .addConverterFactory(GsonConverterFactory.create()) .build() private val api = retrofit.create(UserApi::class.java) fun getUser(userId: String, callback: UserCallback) { api.getUser(userId).enqueue(object : Callback<User> { override fun onResponse(call: Call<User>, response: Response<User>) { if (response.isSuccessful()) { callback.onSuccess(response.body()!!) } else { callback.onError("Server error ${response.code()}") } } override fun onFailure(call: Call<User>, t: Throwable) { callback.onError(t.message ?: "Unknown error") } }) } }
// ⚠ CONTROLLER — Owns logic but still references Activity // ❌ Can't test without Android framework due to Context class UserController(private val activity: UserProfileActivity) { private val userService = UserService() fun loadUser(userId: String) { // Tell View to show loading activity.showLoading() userService.getUser(userId, object : UserCallback { override fun onSuccess(user: User) { // ❌ activity could be destroyed by now! activity.showUser(user) } override fun onError(message: String) { activity.showError(message) } }) } }
// ⚠ VIEW — Activity as View. Simpler than God Activity but // ❌ It creates the Controller itself — tight coupling // ❌ Controller has direct Activity reference → memory leak risk class UserProfileActivity : AppCompatActivity() { private lateinit var binding: ActivityUserProfileBinding private lateinit var controller: UserController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityUserProfileBinding.inflate(layoutInflater) controller = UserController(this) // ❌ passes 'this' — tight coupling setContentView(binding.root) val userId = intent.getStringExtra("USER_ID") ?: return controller.loadUser(userId) } // "View" methods called by Controller fun showLoading() { binding.progressBar.visibility = View.VISIBLE binding.errorText.visibility = View.GONE binding.content.visibility = View.GONE } fun showUser(user: User) { binding.progressBar.visibility = View.GONE binding.content.visibility = View.VISIBLE binding.tvName.text = user.name binding.tvEmail.text = user.email Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar) } fun showError(message: String) { binding.progressBar.visibility = View.GONE binding.errorText.visibility = View.VISIBLE binding.errorText.text = message } }
Progress: User is now a proper data class. UserService is reusable and partially testable. Still broken: Controller holds Activity reference — rotate screen mid-request → crash. Controller can't be unit-tested (it needs the Activity). View and Controller are tightly coupled.
02
⚠ What MVP introduced
Requires a View interface for every screen. Manual attachView() / detachView() in every lifecycle method — forget once → memory leak or crash.✅ What improved over MVC
Presenter has ZERO Activity references. It only knows IUserView. Mock that interface in tests — Presenter is fully testable with pure JUnit, no Android needed.// ✅ CONTRACT — The interface between View and Presenter // This is the KEY innovation of MVP interface UserContract { // What the VIEW must provide (Activity implements this) interface View { fun showLoading() fun hideLoading() fun showUser(user: User) fun showError(message: String) } // What the PRESENTER must provide (Activity calls this) interface Presenter { fun attachView(view: View) fun detachView() fun loadUser(userId: String) } }
// ✅ PRESENTER — Pure Kotlin, zero Android imports // Holds IView reference (not Activity) → unit-testable! class UserPresenter( private val repository: UserRepository ) : UserContract.Presenter { // ✅ Interface reference — not the Activity itself private var view: UserContract.View? = null override fun attachView(view: UserContract.View) { this.view = view } override fun detachView() { this.view = null } override fun loadUser(userId: String) { view?.showLoading() repository.getUser(userId, object : UserCallback { override fun onSuccess(user: User) { // ✅ Safe null check — view is null if Activity was destroyed view?.hideLoading() view?.showUser(user) } override fun onError(message: String) { view?.hideLoading() view?.showError(message) } }) } }
// ✅ MODEL — Repository pattern separates data concerns // Can be swapped for fake in tests interface UserRepository { fun getUser(userId: String, callback: UserCallback) } class UserRepositoryImpl(private val api: UserApi) : UserRepository { override fun getUser(userId: String, callback: UserCallback) { api.getUser(userId).enqueue(object : Callback<User> { override fun onResponse(call: Call<User>, res: Response<User>) { if (res.isSuccessful()) callback.onSuccess(res.body()!!) else callback.onError("Server error") } override fun onFailure(call: Call<User>, t: Throwable) { callback.onError(t.message ?: "Error") } }) } }
// ✅ VIEW — Activity is now a thin shell // ⚠ Must remember attachView/detachView — easy to forget! class UserProfileActivity : AppCompatActivity(), UserContract.View { private lateinit var binding: ActivityUserProfileBinding private lateinit var presenter: UserContract.Presenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityUserProfileBinding.inflate(layoutInflater) presenter = UserPresenter(UserRepositoryImpl(ApiClient.api)) setContentView(binding.root) presenter.attachView(this) // ⚠ Don't forget! intent.getStringExtra("USER_ID")?.let { presenter.loadUser(it) } } override fun onDestroy() { presenter.detachView() // ⚠ Don't forget! Memory leak if missed super.onDestroy() } override fun showLoading() { binding.progressBar.visibility = View.VISIBLE binding.content.visibility = View.GONE } override fun hideLoading() { binding.progressBar.visibility = View.GONE } override fun showUser(user: User) { binding.content.visibility = View.VISIBLE binding.tvName.text = user.name binding.tvEmail.text = user.email Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar) } override fun showError(msg: String) { binding.errorText.visibility = View.VISIBLE binding.errorText.text = msg } }
// ✅ UNIT TEST — No Android SDK needed at all! // This is MVP's biggest win class UserPresenterTest { // Mock the View interface — not the Activity private val mockView = mock(UserContract.View::class.java) private val mockRepository = mock(UserRepository::class.java) private val presenter = UserPresenter(mockRepository) @Before fun setup() { presenter.attachView(mockView) } @Test fun `loadUser shows loading then user on success`() { val fakeUser = User("1", "Alice", "alice@test.com", "http://...") // Arrange: repository will call onSuccess whenever(mockRepository.getUser(any(), any())).thenAnswer { (it.arguments[1] as UserCallback).onSuccess(fakeUser) } // Act presenter.loadUser("1") // Assert — verify View methods were called correctly verify(mockView).showLoading() verify(mockView).hideLoading() verify(mockView).showUser(fakeUser) verifyNoMoreInteractions(mockView) } }
Big win: Check the test file — pure JUnit, zero Android, full Presenter coverage. Still painful: Every screen needs a Contract interface (2–3 interfaces per feature). Manual
attachView/detachView with no safety net. Rotation recreates Activity — Presenter must be kept alive manually or state is lost.
03
⚠ Remaining issues
Multiple StateFlow properties can result in inconsistent states (isLoading=true AND data non-null at the same time). Events (navigation) re-deliver on rotation with SharedFlow. No standard single source of truth.✅ What improved over MVP
ViewModel survives config changes with zero code. No View references in ViewModel ever. viewModelScope cancels all coroutines automatically. No more attachView/detachView. Official Jetpack support.// ✅ VIEWMODEL — Survives rotation, zero View references // viewModelScope auto-cancels all coroutines on clear() class UserViewModel(private val repository: UserRepository) : ViewModel() { // ⚠ Multiple StateFlows — can get out of sync private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() private val _user = MutableStateFlow<User?>(null) val user: StateFlow<User?> = _user.asStateFlow() private val _error = MutableStateFlow<String?>(null) val error: StateFlow<String?> = _error.asStateFlow() fun loadUser(userId: String) { // ✅ viewModelScope — coroutine cancelled when ViewModel is cleared viewModelScope.launch { _isLoading.value = true _error.value = null // ✅ suspend function — reads like synchronous code repository.getUser(userId) .onSuccess { user -> _user.value = user _isLoading.value = false } .onFailure { t -> _error.value = t.message _isLoading.value = false } } } }
// ✅ REPOSITORY — Suspend functions, single source of truth // Combines local DB (Room) + remote API (Retrofit) interface UserRepository { suspend fun getUser(userId: String): Result<User> } class UserRepositoryImpl( private val api: UserApi, private val userDao: UserDao // Room DAO ) : UserRepository { override suspend fun getUser(userId: String): Result<User> { // Try cache first userDao.getUser(userId)?.let { return Result.success(it.toDomain()) } // Fetch from network return runCatching { val dto = api.getUser(userId) userDao.insertUser(dto.toEntity()) // Cache locally dto.toDomain() } } }
// ✅ VIEW — Activity is very thin, just observes // ✅ viewModels() delegate handles ViewModel lifecycle class UserProfileActivity : AppCompatActivity() { private lateinit var binding: ActivityUserProfileBinding // ✅ ViewModels survive rotation — no manual save/restore private val viewModel: UserViewModel by viewModels { UserViewModelFactory(UserRepositoryImpl(ApiClient.api, AppDatabase.dao)) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityUserProfileBinding.inflate(layoutInflater) setContentView(binding.root) // ✅ Collect lifecycle-safely — auto-stops when Activity is stopped lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.isLoading.collect { setLoading(it) } } launch { viewModel.user.collect { it?.let { u -> showUser(u) } } } launch { viewModel.error.collect { it?.let { e -> showError(e) } } } } } intent.getStringExtra("USER_ID")?.let { viewModel.loadUser(it) } } private fun setLoading(loading: Boolean) { binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE } private fun showUser(user: User) { binding.tvName.text = user.name binding.tvEmail.text = user.email Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar) } private fun showError(msg: String) { binding.errorText.text = msg } }
// ✅ COMPOSE VERSION — collectAsState() + MVVM = perfect pair @Composable fun UserProfileScreen( userId: String, viewModel: UserViewModel = hiltViewModel() ) { val isLoading by viewModel.isLoading.collectAsState() val user by viewModel.user.collectAsState() val error by viewModel.error.collectAsState() LaunchedEffect(userId) { viewModel.loadUser(userId) } Box(modifier = Modifier.fillMaxSize()) { when { isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) error != null -> Text(text = error!!, color = Color.Red) user != null -> UserContent(user = user!!) } } }
// ✅ VIEWMODEL TEST — Uses TestCoroutineDispatcher // No Android framework needed @ExperimentalCoroutinesApi class UserViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val fakeRepo = mockk<UserRepository>() private val viewModel = UserViewModel(fakeRepo) @Test fun `loadUser emits user on success`() = runTest { val fakeUser = User("1", "Alice", "alice@test.com", "") coEvery { fakeRepo.getUser("1") } returns Result.success(fakeUser) viewModel.loadUser("1") assertEquals(fakeUser, viewModel.user.value) assertEquals(false, viewModel.isLoading.value) assertNull( viewModel.error.value) } }
Huge upgrade: ViewModel lives through rotation.
viewModelScope cancels network calls on exit. No more callback hell — suspend functions. Notice the problem: Three separate StateFlow fields can drift — set isLoading=false but forget to set user value → UI in broken intermediate state. MVI fixes this.
04
⚠ Trade-off
More classes per feature (State, Intent, Effect). Steeper learning curve. Reducer pattern may feel foreign at first. Slight overhead copying data class each update.✅ What improved over MVVM
Single UiState — impossible states are impossible. Every state is reproducible and loggable. Side effects (navigation) via Channel (consumed once, no re-delivery). Reducer is a pure function — trivially tested.// ✅ SINGLE STATE — All possible UI states in one sealed class // isLoading=true AND data≠null simultaneously? STRUCTURALLY IMPOSSIBLE // Every possible state the screen can be in data class UserUiState( val isLoading: Boolean = false, val user: User? = null, val error: String? = null ) // User actions become explicit Intent events sealed class UserIntent { data class LoadUser(val userId: String) : UserIntent() object RetryLoad : UserIntent() object DismissError : UserIntent() } // One-off side effects — NOT part of persistent state // Channel ensures each effect is consumed exactly ONCE sealed class UserEffect { data class ShowToast(val message: String) : UserEffect() object NavigateBack : UserEffect() }
// ✅ VIEWMODEL with Reducer — processes Intents → emits new State // ✅ updateState() is atomic — no intermediate inconsistent states class UserViewModel(private val repository: UserRepository) : ViewModel() { // ✅ ONE state object — impossible states are impossible private val _uiState = MutableStateFlow(UserUiState()) val uiState: StateFlow<UserUiState> = _uiState.asStateFlow() // ✅ Channel — effects consumed exactly once, no re-delivery on rotation private val _effect = Channel<UserEffect>(Channel.BUFFERED) val effect: Flow<UserEffect> = _effect.receiveAsFlow() // ✅ Single entry point for all user actions fun handleIntent(intent: UserIntent) { when (intent) { is UserIntent.LoadUser -> loadUser(intent.userId) is UserIntent.RetryLoad -> _uiState.value.user?.id?.let { loadUser(it) } is UserIntent.DismissError -> updateState { copy(error = null) } } } private fun loadUser(userId: String) { viewModelScope.launch { // ✅ Single atomic update — all fields change together updateState { copy(isLoading = true, error = null, user = null) } repository.getUser(userId) .onSuccess { user -> updateState { copy(isLoading = false, user = user) } _effect.send(UserEffect.ShowToast("Welcome, ${user.name}!")) } .onFailure { t -> updateState { copy(isLoading = false, error = t.message) } } } } // ✅ Helper — atomic state update, thread-safe private fun updateState(reduce: UserUiState.() -> UserUiState) { _uiState.update { it.reduce() } } }
// ✅ COMPOSE + MVI — single state drives the entire UI // collectAsState() + when(uiState) = perfectly predictable @Composable fun UserProfileScreen( userId: String, viewModel: UserViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current // ✅ Effect consumed once — no re-delivery on recompose/rotation LaunchedEffect(Unit) { viewModel.effect.collect { effect -> when (effect) { is UserEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() is UserEffect.NavigateBack -> { /* navigate */ } } } } LaunchedEffect(userId) { viewModel.handleIntent(UserIntent.LoadUser(userId)) } // ✅ Renders based on SINGLE state — always consistent Box(modifier = Modifier.fillMaxSize()) { when { uiState.isLoading -> LoadingView() uiState.error != null -> ErrorView( message = uiState.error!!, onRetry = { viewModel.handleIntent(UserIntent.RetryLoad) } ) uiState.user != null -> UserContent(user = uiState.user!!) else -> EmptyView() } } }
// ✅ PUREST TESTS — State-in → State-out, no mocks needed for assertions // Just check: "given state X + intent Y → state Z" class UserViewModelTest { @get:Rule val dispatcher = MainDispatcherRule() private val fakeRepo = mockk<UserRepository>() private val viewModel = UserViewModel(fakeRepo) @Test fun `LoadUser intent transitions to loading then success state`() = runTest { val fakeUser = User("1", "Alice", "alice@test.com", "") coEvery { fakeRepo.getUser("1") } returns Result.success(fakeUser) viewModel.handleIntent(UserIntent.LoadUser("1")) // Assert final state — single object, easy to read assertEquals( UserUiState(isLoading = false, user = fakeUser, error = null), viewModel.uiState.value ) } @Test fun `DismissError intent clears error from state`() = runTest { // Seed state with an error coEvery { fakeRepo.getUser(any()) } throws Exception("Network error") viewModel.handleIntent(UserIntent.LoadUser("1")) assertNotNull(viewModel.uiState.value.error) // Dismiss — error should be gone viewModel.handleIntent(UserIntent.DismissError) assertNull(viewModel.uiState.value.error) } }
Notice: The
updateState { copy(...) } pattern atomically transitions state — you can never be half-loading-half-success. Tests are the simplest yet — compare entire state objects, no Mockito verify() chains. Effects via Channel solve the rotation re-delivery bug permanently.
05
⚠ Complexity cost
More files per feature: Domain model, Data model, DTO, two mappers, a Repository interface, Use Case class. Overkill for a 2-screen app. Requires Hilt/Dagger for DI.✅ What it adds over MVI alone
Business logic in Use Cases — reusable across ViewModels. Domain layer is framework-free. Swap Room for Firestore — ViewModel doesn't change. ViewModel doesn't change when business rules change. Perfect isolation at every boundary.// ✅ DOMAIN LAYER — :domain module // Pure Kotlin. Zero Android SDK imports. The most stable code. // This model is OWNED by Domain — it reflects business concepts, // NOT API response shape or database schema data class User( // Domain model — NOT a DTO, NOT a Room entity val id: String, val name: String, val email: String, val avatarUrl: String, val isPremium: Boolean // Business concept — not in API, derived by domain ) // ✅ Sealed Result type owned by Domain — not Android's Result class sealed class DomainResult<out T> { data class Success<T>(val data: T) : DomainResult<T>() data class Error(val exception: DomainException) : DomainResult<Nothing>() } // Domain defines its OWN exceptions — not IOException or HttpException sealed class DomainException(message: String) : Exception(message) { class UserNotFound(id: String) : DomainException("User $id not found") class NetworkError(cause: String): DomainException(cause) object Unauthorized : DomainException("Unauthorized") }
// ✅ USE CASE — :domain module, pure Kotlin, single responsibility // THIS is where business logic lives — not in ViewModel, not in Repository // "A user can only be loaded if the current session is valid" // "Premium status is derived from subscription expiry date" class GetUserUseCase( private val userRepository: UserRepository, // Domain interface private val sessionRepository: SessionRepository // Domain interface ) { // ✅ suspend operator fun — call as: getUser("123") suspend operator fun invoke(userId: String): DomainResult<User> { // Business rule 1: Must be authenticated if (!sessionRepository.isAuthenticated()) { return DomainResult.Error(DomainException.Unauthorized) } return userRepository.getUser(userId).fold( onSuccess = { userDto -> // Business rule 2: Premium derived from subscription end date val isPremium = userDto.subscriptionEndDate?.isAfterNow() ?: false DomainResult.Success( User( id = userDto.id, name = userDto.name, email = userDto.email, avatarUrl = userDto.avatarUrl, isPremium = isPremium // ← Business logic in Domain, not ViewModel ) ) }, onFailure = { t -> DomainResult.Error(DomainException.NetworkError(t.message ?: "Error")) } ) } }
// ✅ REPOSITORY INTERFACE — :domain module // Domain DEFINES the interface. Data layer IMPLEMENTS it. // Dependency Inversion Principle — the crown jewel of Clean Architecture // Swap SQLite for Firestore? Only :data module changes. Domain is untouched. interface UserRepository { suspend fun getUser(userId: String): Result<UserDto> suspend fun saveUser(user: User): Result<Unit> } interface SessionRepository { suspend fun isAuthenticated(): Boolean suspend fun getCurrentUserId(): String? } // DTO lives at the boundary between Domain and Data data class UserDto( val id: String, val name: String, val email: String, val avatarUrl: String, val subscriptionEndDate: String? // Raw from API — Domain interprets this )
// ✅ REPOSITORY IMPLEMENTATION — :data module // Knows about Room, Retrofit. Domain knows NOTHING about these. class UserRepositoryImpl( private val api: UserApi, private val userDao: UserDao ) : UserRepository { // ← Implements Domain interface override suspend fun getUser(userId: String): Result<UserDto> { // 1. Try Room cache first userDao.getUser(userId)?.let { return Result.success(it.toDto()) // Entity → DTO mapper } // 2. Fetch from Retrofit return runCatching { val response = api.getUser(userId) // UserApiResponse (API model) userDao.insertUser(response.toEntity()) // ApiResponse → Room Entity response.toDto() // ApiResponse → DTO } } override suspend fun saveUser(user: User): Result<Unit> = runCatching { userDao.insertUser(user.toEntity()) } } // ✅ Mappers keep each layer's model clean fun UserEntity.toDto() = UserDto(id, name, email, avatarUrl, subscriptionEndDate) fun UserApiResponse.toDto() = UserDto(id, name, email, avatarUrl, subscriptionEndDate) fun UserApiResponse.toEntity() = UserEntity(id, name, email, avatarUrl, subscriptionEndDate)
// ✅ VIEWMODEL — :ui module, uses Use Case (not Repository directly!) // ViewModel is THIN — just orchestrates Use Case calls and maps results to UiState // Notice: no business logic here. "isPremium" logic is in the Use Case. @HiltViewModel class UserViewModel @Inject constructor( private val getUserUseCase: GetUserUseCase // ← Use Case, not Repository ) : ViewModel() { private val _uiState = MutableStateFlow(UserUiState()) val uiState = _uiState.asStateFlow() private val _effect = Channel<UserEffect>(Channel.BUFFERED) val effect = _effect.receiveAsFlow() fun handleIntent(intent: UserIntent) { when (intent) { is UserIntent.LoadUser -> loadUser(intent.userId) is UserIntent.RetryLoad -> _uiState.value.user?.id?.let { loadUser(it) } is UserIntent.DismissError -> updateState { copy(error = null) } } } private fun loadUser(userId: String) { viewModelScope.launch { updateState { copy(isLoading = true, error = null) } // ✅ Use Case called — business rules run inside, ViewModel stays thin when (val result = getUserUseCase(userId)) { is DomainResult.Success -> { updateState { copy(isLoading = false, user = result.data) } if (result.data.isPremium) { _effect.send(UserEffect.ShowToast("✦ Premium member!")) } } is DomainResult.Error -> { val msg = when (result.exception) { is DomainException.Unauthorized -> "Please log in again" is DomainException.UserNotFound -> "User not found" is DomainException.NetworkError -> result.exception.message ?: "Network error" } updateState { copy(isLoading = false, error = msg) } } } } } private fun updateState(reduce: UserUiState.() -> UserUiState) = _uiState.update { it.reduce() } }
// ✅ DEPENDENCY INJECTION — Hilt wires everything together // The entire dependency graph is defined here — not scattered across classes @Module @InstallIn(SingletonComponent::class) object UserModule { @Provides @Singleton fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java) @Provides @Singleton fun provideUserRepository( // ← Returns Domain interface type api: UserApi, dao: UserDao ): UserRepository = UserRepositoryImpl(api, dao) @Provides fun provideGetUserUseCase( userRepo: UserRepository, sessionRepo: SessionRepository ): GetUserUseCase = GetUserUseCase(userRepo, sessionRepo) }
// ✅ USE CASE TESTS — Pure Kotlin, no Android, no Compose, no Room // Tests are deterministic: inject fake repos → assert business logic // This is the cleanest, fastest test suite in the entire codebase class GetUserUseCaseTest { private val userRepo = mockk<UserRepository>() private val sessionRepo = mockk<SessionRepository>() private val useCase = GetUserUseCase(userRepo, sessionRepo) @Test fun `returns Unauthorized when not authenticated`() = runTest { coEvery { sessionRepo.isAuthenticated() } returns false val result = useCase("1") assertIs<DomainResult.Error>(result) assertIs<DomainException.Unauthorized>(result.exception) } @Test fun `marks user as premium when subscription is active`() = runTest { coEvery { sessionRepo.isAuthenticated() } returns true coEvery { userRepo.getUser("1") } returns Result.success( UserDto("1", "Alice", "alice@test.com", "", subscriptionEndDate = "2099-01-01") ) val result = useCase("1") assertIs<DomainResult.Success<User>>(result) assertTrue(result.data.isPremium) // ← Business rule tested in isolation } @Test fun `marks user as NOT premium when subscription expired`() = runTest { coEvery { sessionRepo.isAuthenticated() } returns true coEvery { userRepo.getUser("1") } returns Result.success( UserDto("1", "Bob", "bob@test.com", "", subscriptionEndDate = "2020-01-01") ) val result = useCase("1") assertIs<DomainResult.Success<User>>(result) assertFalse(result.data.isPremium) } }
The payoff: Look at the Use Case test — it tests the business rule "isPremium is derived from subscriptionEndDate" in complete isolation. No Activity, no ViewModel, no Room, no Retrofit, no Android. Just pure logic. This is the summit of Android architecture: every layer independently testable, every boundary explicit, every dependency pointing inward.